Can you believe it has been 2 years since I last wrote my post? These blogs take a long time to write.
In Part 1, we kicked off our comparison between Rust and Modern C++ (focusing on C++11/14 standards and philosophies like those in Effective Modern C++). We looked at the basics: initializing data structures, iteration, lambdas, and smart pointers. The verdict? Rust often provided cleaner syntax and less ambiguity (winning 3/4 categories in my opinion), though C++ smart pointers felt simpler initially compared to Rust's Box
, Arc
, Rc
, and the underlying rules.
My core thesis remains:
Modern C++ is arguably filled with gotcha's, edgecases, and solutions to problems stemming from C++ limitations.
As a result, Rust benefits from faster design cycles, more efficient code, and is arguablely an easier language to maintain even with its current rough edges in certain edge cases.
Today, we dive into the heart of what makes Rust... well, Rust. We'll explore two foundational concepts: Traits (Rust's answer to interfaces and generic constraints) and the infamous Ownership and Borrowing system.
As scientists begin to solve problems, it is inevitable we start to see patterns. More specifically, we realize how we interact with different types can be similar. Consider the concept of shape. Every concave shape has a defined number of sides. A good programming language should allow for the concept of polymorhism in their data types such that different types can be passed to the same function.
How do we write generic code that works with different types, ensuring those types can actually do what our code expects? Both languages have mechanisms for this. We will be focusing on polymorphism which are a property of classes that allow for simlar or shared interfaces. Generics will also be briefly covered which are used in algorithms or functions but will not be the highlight of this article.
Modern C++ primarily builds off class inheritance and templates as means for type-generics. C++ rather cleanly splits them into runtime based and compile-time based resolutions.
The classic OOP approach. Define a base class with virtual
functions, and derived classes override them. This gives runtime polymorphism but comes with costs: vtable lookup overhead, potential for object slicing, rigid hierarchies, and the complexities of multiple inheritance ("the diamond problem").
Runtime polymorphism is when types are resolved at runtime. Vtables are the primary ways of resolving.
#include <iostream>
struct Drawable {
virtual ~Drawable() = default; // Important! Virtual destructor
virtual void draw() const = 0; // Pure virtual function (interface)
};
struct Circle : Drawable {
void draw() const override { std::cout << "Drawing Circle" << std::endl; }
};
struct Square : Drawable {
void draw() const override { std::cout << "Drawing Square" << std::endl; }
};
void draw_all(const std::vector<std::unique_ptr<Drawable>>& shapes) {
for (const auto& shape : shapes) {
shape->draw(); // Dynamic dispatch via vtable
}
}
C++'s powerhouse for static polymorphism. Instead of waiting for a lookup table during runtime, templates resolve during compile-time.
Code is generated at compile time for each type used. Blazingly fast during runtime, but historically plagued by terrible error messages if a type didn't meet implicit requirements (the infamous "template metaprogramming wall of text"). Pre-C++20, techniques like SFINAE (Substitution Failure Is Not An Error) were used to constrain templates, often leading to complex code. Furthermore, compile time can easily increase non-linearlly. Template resolution can easily grow linearly by type and number of typed parameters if developers rely on fall-back implementations as the compiler would need to each combination.
The rule of Chiel is an example of how developers attempt to optimize and reduce template metaprograms. These were rules used when designing the boost library as this SO post mentions:
Optimizations based on the above rules were used when Boost.TMP was designed and developed. As much as possible, avoid top constructs for quick template compilation.
You can find somework on verifying this theory here. From highest cost to lowest:
Otherwise known as: Subsistution Failure is not an Error. A rule that allows C++ to exhaustively try every template definition for a given type if the most likely one fails as a fall-back. Much of the conditional templates in C++14 captilize on this behavior to for use in metaprogramming.
The simple example:
template<class T, class U>
struct is_same : std::false_type {};
template<class T>
struct is_same<T, T> : std::true_type {};
// This example shows how easy it is for C++ to bruteforce templates until one works.
// The rule of thumb is that the most specific template will be tried first.
is_same<int, int>() // Resolves to the bottom most template and returns true always.
is_same<int, bool>() // fails the bottom most template, however SFINAE and goes to the first, always returning false.
Here is a more complex one:
template <typename T>
class Container {
private:
std::vector<T> items;
public:
void add(const T& item) {
items.push_back(item);
}
void print() const {
std::cout << "Container contents: ";
for (const auto& item : items) {
std::cout << item << " ";
}
std::cout << std::endl;
}
// SFINAE in action:
// This 'sum()' member function template is enabled ONLY IF std::is_arithmetic<T>::value is true.
// We use std::enable_if in the return type.
// If the condition (std::is_arithmetic<T>::value) is false, std::enable_if<...>::type is ill-formed.
// SFINAE kicks in: this function signature is invalid, so it's removed from the class definition
// for non-arithmetic types, instead of causing a hard compiler error.
template <typename U = T> // Dummy template param often needed for SFINAE on member fns dependent on class params
typename std::enable_if<std::is_arithmetic<U>::value, U>::type
sum() const {
std::cout << "(Calculating sum for arithmetic type)" << std::endl;
// Need a default value appropriate for arithmetic types
T total = T(); // Default constructor (0 for built-in arithmetic types)
return std::accumulate(items.begin(), items.end(), total);
}
};
void f1(auto);
void f2(C1 auto);
// Or older syntax
template <typename T>
void my_func(T x);
template <typename T>
class Container {
private:
std::vector<T> items;
public:
void add(T item) {
items.push_back(item);
}
};
int main() {
Container<int> int_container;
Container<std::string> string_container;
Container<double> double_container;
return 0;
}
template<typename T>
constexpr void my_function();
template<typename T>
constexpr auto alias_name = my_function<T>;
alias_name<int>(); // Still has to resolve template but now an extra indirection.
// Template with one parameter
template <typename T>
struct SimpleBox {
T value;
};
// Template with three parameters
template <typename T1, typename T2, int Size>
struct ComplexBox {
T1 value1;
T2 value2;
int data[Size];
};
// Alias with one parameter
template <typename T>
using Vec = std::vector<T>;
// Alias with two parameters (value type and allocator)
template <typename T, typename Alloc = std::allocator<T>>
using AllocVec = std::vector<T, Alloc>;
It wasn't clear to me from the talk what "memoized" meant here however it could either mean basic type lookup where resolution is already in memory or if templates are memoized after being resolved:
#include <vector>
#include <iostream>
// Function using std::vector<int>
void process_int_vector(const std::vector<int>& vec) {
std::cout << "Processing vector with size: " << vec.size() << std::endl;
// ... process ...
}
// Another function also using std::vector<int>
std::vector<int> create_int_vector(int n) {
std::vector<int> new_vec; // Requires definition of std::vector<int>
for (int i = 0; i < n; ++i) {
new_vec.push_back(i * 2);
}
return new_vec;
}
int main() {
// The compiler needs the definition of std::vector<int> here.
// It likely instantiates it fully if this is the first use.
std::vector<int> my_vector = create_int_vector(5);
// The compiler also needs std::vector<int> here.
// It can likely reuse ("look up") the definition it
// already generated/instantiated previously. This lookup is
// considered relatively cheap compared to a full new instantiation.
process_int_vector(my_vector);
std::vector<int> another_vector; // Reuse again
return 0;
}
A huge improvement introduced after our C++14 baseline, but essential for a truly modern comparison. Concepts allow you to explicitly define requirements and chain them (called, constraints) for template parameters.
This construct is entirely optional and can be considered as being built on-top of templates. This can be an entire article on its own.
Concepts effectively allow you to check certain properties about a type, chain these properties together (called constraints) and catch template errors earlier onwards. It will probably be the recommended way of template programming C++20 and onwards as error messages are cleaner.
#include <concepts> // C++20
#include <iostream>
// Define a concept 'Drawable' requiring a 'draw()' member function
template<typename T>
concept Drawable = requires(const T& t) {
{ t.draw() } -> std::same_as<void>; // Check if t.draw() exists and returns void
};
// Constrain the template using the concept
template<Drawable T>
void draw_static(const T& shape) {
shape.draw(); // Static dispatch
}
struct Triangle { // Doesn't inherit, just needs the method
void draw() const { std::cout << "Drawing Triangle (Concept)" << std::endl; }
};
// Usage:
// Triangle t;
// draw_static(t); // Compiles because Triangle satisfies Drawable concept
Error example from cppreference shows how clean errors can be:
std::list<int> l = {3, -1, 10};
std::sort(l.begin(), l.end());
// Typical compiler diagnostic without concepts:
// invalid operands to binary expression ('std::_List_iterator<int>' and
// 'std::_List_iterator<int>')
// std::__lg(__last - __first) * 2);
// ~~~~~~ ^ ~~~~~~~
// ... 50 lines of output ...
//
// Typical compiler diagnostic with concepts:
// error: cannot call std::sort with std::_List_iterator<int>
// note: concept RandomAccessIterator<std::_List_iterator<int>> was not satisfied
Rust really only has one supported way of polymorphism for class types and that is through Traits
.
Traits
are primarily resolved statically but also incorporate runtime polymorphism.
You can think of traits as incorporating some parts of C++ "concepts" as a means of "constraining" with interface-like definitions.
Though we are mainly focusing on classes and struct polymorphism, it can be noted that Rust does support functional generics which are essentially similar in syntax to C++ functional templates however do not support SFINAE and other complex type-systems that C++ offers.
Define a contract with trait
, implement it with impl Trait for Type
. You can think of it as defining "interface" classes but do not have to be purely abstract.
// Define the trait (interface)
trait Summary {
fn summarize(&self) -> String; // Method signature
// Traits can also have default implementations
fn default_summary(&self) -> String {
String::from("(Read more...)")
}
}
struct NewsArticle {
headline: String,
content: String,
}
// Implement the trait for NewsArticle
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, {}", self.headline, self.default_summary())
}
}
struct Tweet {
username: String,
content: String,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Use traits to ensure generic functions get types are constrainted by their behavior. It is similar to constraints
in C++ however is considered "nominal type" which is explicit.
The type must satisfy a struct that derives from this trait, no exceptions. Constraints in C++, however, are entirely optional.
// Constrain T to types implementing the Summary trait
fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
// Alternative 'where' clause syntax
fn notify_verbose<T>(item: &T)
where
T: Summary, // Requires Summary trait
{
println!("Detailed report: {}", item.summarize());
}
More than one constraint can be added per-generic, making this system very powerful:
// Alternative 'where' clause syntax
fn notify_verbose<T>(item: &T)
where
T: Summary + AnotherConstraint + YetAnother,
{
println!("Detailed report: {}", item.summarize());
}
Traits enable both:
notify::<NewsArticle>(...)
). This is the default and has zero runtime overhead.dyn Trait
to create objects that hide the concrete type but guarantee they implement the trait, similar to C++ virtual functions (but explicit). Requires pointers like &dyn Summary
or Box<dyn Summary>
.fn dynamic_notify(item: &dyn Summary) { // Takes a reference to any type implementing Summary
println!("Dynamic dispatch: {}", item.summarize());
}
// Usage:
// let article = NewsArticle { ... };
// let tweet = Tweet { ... };
// dynamic_notify(&article);
// dynamic_notify(&tweet);
// let summaries: Vec<Box<dyn Summary>> = vec![Box::new(article), Box::new(tweet)];
Let's compare the two languages:
requires
expressions and constraints
on almost any C++ type. While traits are limited to structs and class methods.Verdict: Rust.
While C++20 Concepts are a fantastic and much-needed addition that significantly narrows the gap (especially regarding clear generic constraints), Rust's trait system feels more foundational, unified, and inherently integrated into the language's approach to abstraction and polymorphism. It avoids the historical baggage and separate mechanisms found in C++.
This is arguably the biggest differentiator. How do we manage resources (especially memory) safely and prevent common bugs like dangling pointers or data races?
As discussed in Part 1, Modern C++ relies heavily on RAII (Resource Acquisition Is Initialization). Object lifetime manages resource lifetime. Smart pointers (std::unique_ptr
, std::shared_ptr
) automate this for heap allocations.
std::unique_ptr
: Exclusive ownership. The resource is deleted when the unique_ptr
goes out of scope. Cheap, efficient.std::shared_ptr
: Shared ownership via reference counting. The resource is deleted only when the last shared_ptr
referencing it is destroyed. Incurs reference counting overhead.This system works well most of the time and is much safer than manual new
/delete
. However, pitfalls remain:
std::weak_ptr
in cyclic shared_ptr
scenarios.C++ basically says, the developer has to be the one to ensure safetly. This assumes developers can identify complex code manually and determine if a pointer was left unaddressed or some data race had occurred.
Rust enforces memory safety at compile time through a strict set of rules enforced by the "borrow checker" introduced by the compiler on all variables:
Each value in Rust has a single owner:
Copy
type like simple integers).{
let s1 = String::from("hello"); // s1 owns the String data
let s2 = s1; // Ownership of the String data MOVES from s1 to s2
// println!("{}", s1); // ERROR! s1 is no longer valid, ownership moved
println!("{}", s2);
} // s2 goes out of scope, the String data is dropped
Instead of moving ownership, you can lend access via references (borrows).
&T
) simultaneously.&mut T
) at a time.let mut s = String::from("hello");
let r1 = &s; // OK - immutable borrow
let r2 = &s; // OK - another immutable borrow
println!("{}, {}", r1, r2); // OK
// let r3 = &mut s; // ERROR! Cannot borrow `s` as mutable because it is also borrowed as immutable
// Scope of r1 and r2 ends here implicitly because they are no longer used.
let r3 = &mut s; // OK - no other borrows active
r3.push_str(", world!");
println!("{}", r3);
Another way to phrase is it that all variables are constant. If it isn't constant, only one reference can modify it at a time.
The compiler tracks the scope for which references are valid, ensuring they never outlive the data they point to (preventing dangling references). Often inferred (elided), but sometimes require explicit annotation ('a
).
// fn dangling_ref() -> &String { // ERROR: returns reference to data owned by the function
// let s = String::from("hello");
// &s // reference to `s`, but `s` will be dropped here!
// }
The combination of these rules allows Rust to guarantee memory safety and data-race freedom (in safe Rust code) at compile time without needing a garbage collector.
The rules for the borrow-checker applies for all variables within Rust, however, what about objects that exist on the heap that exist beyond the lifetime of the scope? These traits ensure certain properties about the memory being pointed by these variables that ensure the borrow-checker's check on references will propogated to the heap.
Rust also introduces more traits and concepts for asynchronous coding which we will not be covering in this article. But streams and channels help prevent data-races at a minor overhead.
Let's compare the two languages.
unsafe
provides an escape hatch). Certain patterns (like mutable doubly linked lists) are notoriously harder in safe Rust due to the borrow rules.shared_ptr
has runtime ref-counting overhead; unique_ptr
is closer to Rust's Box
.Verdict: This is the core trade-off. Rust provides unparalleled safety guarantees enforced by the compiler, especially vital for concurrency, but demands a steeper learning curve. Modern C++ offers easier initial adoption for resource management via smart pointers but leaves the door open for subtle, dangerous bugs and requires significant developer discipline, especially in concurrent contexts. For safety and maintainability, especially in complex or concurrent systems, the borrow checker's upfront cost pays dividends later.
We've tackled the heavyweights: Traits and Ownership.
Revisiting the thesis: Do these core Rust features contribute to faster design, efficiency, and maintainability? I believe so. While the borrow checker requires an initial investment, the classes of bugs it prevents significantly reduce debugging time and increase confidence, particularly in large or concurrent systems. Traits provide a clean abstraction mechanism. These factors often outweigh the initial learning curve and the occasional ergonomic challenges compared to the potential pitfalls and complexities lurking in even modern C++.
Rust isn't perfect (as we saw with returning closures in Part 1 and the complexity of some borrowing patterns), but its foundational design choices around traits and ownership offer compelling advantages.
In Part 3, we'll look at Rust's metaprogramming powerhouse (Macros) and its universally acclaimed build system and package manager (Cargo). Stay tuned!